fix: make sentinel trend chart show duration
This commit is contained in:
@@ -343,30 +343,12 @@ select {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.trend-red {
|
||||
fill: none;
|
||||
stroke: var(--red);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.trend-warning {
|
||||
fill: none;
|
||||
stroke: var(--amber);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.trend-total {
|
||||
.trend-duration {
|
||||
fill: none;
|
||||
stroke: var(--blue);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5 6;
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.trend-grid-line {
|
||||
@@ -374,12 +356,10 @@ select {
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.trend-dot-red {
|
||||
fill: var(--red);
|
||||
}
|
||||
|
||||
.trend-dot-warning {
|
||||
fill: var(--amber);
|
||||
.trend-dot-duration {
|
||||
fill: var(--blue);
|
||||
stroke: #ffffff;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.trend-dot-hit {
|
||||
@@ -450,6 +430,10 @@ select {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.legend-swatch.duration {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.legend-swatch.warning {
|
||||
background: var(--amber);
|
||||
}
|
||||
|
||||
@@ -4,123 +4,6 @@ import { createApp, computed, onMounted, ref, watch } from "./vendor/vue.esm-bro
|
||||
|
||||
const bootstrap = readBootstrap();
|
||||
const internalTextPattern = /水合|投影|Trace|trace|Shell|API|DOM|Console|console|Runner|runner|JSONL|steer|facts|分页|HTTP|http|requestfailed|pageerror|Final Response|Code Agent|web-probe|observe|analyzer|终态/u;
|
||||
const checkDisplayCatalog = {
|
||||
"code-agent-card-elapsed-missing": {
|
||||
code: "CHECK-101",
|
||||
title: "耗时未显示",
|
||||
summary: "运行卡片没有显示总耗时,影响判断等待时间。",
|
||||
},
|
||||
"code-agent-card-running-recent-update-missing": {
|
||||
code: "CHECK-102",
|
||||
title: "最近更新时间未显示",
|
||||
summary: "运行中的卡片没有显示最近更新时间,影响判断是否仍在推进。",
|
||||
},
|
||||
"turn-timing-total-elapsed-decrease": {
|
||||
code: "CHECK-103",
|
||||
title: "耗时显示回退",
|
||||
summary: "相邻采样中的总耗时出现回退,作为非阻塞计时报警展示。",
|
||||
},
|
||||
"page-loading-concurrent": {
|
||||
code: "CHECK-201",
|
||||
title: "多个加载状态同时出现",
|
||||
summary: "同一采样点看到多个加载提示,用户可能难以判断当前页面是否已就绪。",
|
||||
},
|
||||
"trace-events-page-read-requestfailed-root-cause": {
|
||||
code: "CHECK-301",
|
||||
title: "运行事件读取失败",
|
||||
summary: "运行事件页面读取失败,需要结合服务端链路确认具体失败位置。",
|
||||
},
|
||||
"runtime-requestfailed": {
|
||||
code: "CHECK-302",
|
||||
title: "页面资源加载失败",
|
||||
summary: "巡检期间捕获到页面资源加载失败,可能影响用户看到的运行状态。",
|
||||
},
|
||||
"runtime-console-alerts": {
|
||||
code: "CHECK-303",
|
||||
title: "页面脚本报警",
|
||||
summary: "巡检期间捕获到页面脚本报警,需要评估是否影响用户操作。",
|
||||
},
|
||||
"workbench-app-shell-transient-not-ready": {
|
||||
code: "CHECK-304",
|
||||
title: "工作台短暂未就绪",
|
||||
summary: "工作台短时间内未完成主界面就绪,作为启动过程上下文保留。",
|
||||
},
|
||||
"cross-page-projection-divergence": {
|
||||
code: "CHECK-401",
|
||||
title: "多页面状态不一致",
|
||||
summary: "同一会话在两个页面中显示的状态持续不一致,可能影响用户判断。",
|
||||
},
|
||||
"cross-page-projection-transient-divergence": {
|
||||
code: "CHECK-402",
|
||||
title: "多页面短暂不一致",
|
||||
summary: "页面切换附近短暂出现显示差异,作为上下文保留。",
|
||||
},
|
||||
"cross-page-projection-controlled-navigation-hydration": {
|
||||
code: "CHECK-403",
|
||||
title: "跳转后页面短暂空白",
|
||||
summary: "页面跳转后短暂空白,当前按非阻塞上下文展示。",
|
||||
},
|
||||
"cross-page-trace-visibility-divergence": {
|
||||
code: "CHECK-404",
|
||||
title: "运行记录显示数量不一致",
|
||||
summary: "两个页面看到的运行记录数量不同,但未直接证明消息状态不一致。",
|
||||
},
|
||||
"page-performance-slow-same-origin-api": {
|
||||
code: "CHECK-501",
|
||||
title: "页面数据加载慢",
|
||||
summary: "页面数据加载超过配置预算,用户可能感觉等待时间过长。",
|
||||
},
|
||||
"page-performance-long-lived-streams": {
|
||||
code: "CHECK-502",
|
||||
title: "长连接持续保持",
|
||||
summary: "长时间连接单独统计,不直接作为页面加载慢处理。",
|
||||
},
|
||||
"natural-api-dom-lag-baseline": {
|
||||
code: "CHECK-503",
|
||||
title: "数据变化可关联",
|
||||
summary: "已采集到数据响应和页面变化样本,可用于分析显示延迟。",
|
||||
},
|
||||
"natural-api-dom-lag-candidates": {
|
||||
code: "CHECK-504",
|
||||
title: "数据更新显示偏慢",
|
||||
summary: "部分数据变化到页面显示的时间超过预算,作为非阻塞调查报警。",
|
||||
},
|
||||
"browser-console-or-page-errors": {
|
||||
code: "CHECK-601",
|
||||
title: "页面脚本错误",
|
||||
summary: "巡检期间捕获到页面脚本错误,需要确认是否影响用户流程。",
|
||||
},
|
||||
"quick-verify-no-business-turn": {
|
||||
code: "CHECK-701",
|
||||
title: "业务轮次未完成",
|
||||
summary: "巡检未确认到稳定的业务交互结果,不能作为恢复证据。",
|
||||
},
|
||||
"frontend-browser-memory-rss-red": {
|
||||
code: "CHECK-801",
|
||||
title: "页面内存超出预算",
|
||||
summary: "页面有效内存超过配置红线,已按阻塞错误展示。",
|
||||
},
|
||||
"frontend-browser-memory-growth-red": {
|
||||
code: "CHECK-802",
|
||||
title: "页面内存持续增长",
|
||||
summary: "页面有效内存在观察窗口内持续增长并超过配置预算,符合卡死风险特征。",
|
||||
},
|
||||
"frontend-browser-freeze-runner-blocker": {
|
||||
code: "CHECK-803",
|
||||
title: "浏览器冻结已阻塞",
|
||||
summary: "浏览器冻结策略已触发,巡检中止页面并报红,不能用刷新或兜底掩盖。",
|
||||
},
|
||||
"frontend-playwright-responsiveness-red": {
|
||||
code: "CHECK-804",
|
||||
title: "页面响应探测超时",
|
||||
summary: "页面响应探测超过配置预算,已按前端卡死处理并报红。",
|
||||
},
|
||||
"frontend-cdp-metrics-timeout-red": {
|
||||
code: "CHECK-805",
|
||||
title: "浏览器指标采集超时",
|
||||
summary: "浏览器侧指标连续采集超时,说明页面运行时可能已经无响应。",
|
||||
},
|
||||
};
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
@@ -165,19 +48,18 @@ createApp({
|
||||
const selectedRun = computed(() => runs.value.find((run) => run.id === selectedRunId.value) || latestRun.value);
|
||||
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 trendMax = computed(() => Math.max(1, ...trendRows.value.flatMap((run) => [trendErrorCount(run), trendWarningCount(run), trendTotalCount(run)])));
|
||||
const trendDurationMax = computed(() => Math.max(0, ...trendRows.value.map((run) => trendDurationMinutes(run))));
|
||||
const trendMax = computed(() => Math.max(1, trendDurationMax.value));
|
||||
const trendPolylines = computed(() => ({
|
||||
red: trendPolyline((run) => trendErrorCount(run)),
|
||||
warning: trendPolyline((run) => trendWarningCount(run)),
|
||||
total: trendPolyline((run) => trendTotalCount(run)),
|
||||
duration: trendPolyline((run) => trendDurationMinutes(run)),
|
||||
}));
|
||||
const trendDots = computed(() => trendRows.value.map((run, index) => {
|
||||
const red = trendErrorCount(run);
|
||||
const warning = trendWarningCount(run);
|
||||
const total = trendTotalCount(run);
|
||||
const duration = trendDurationMinutes(run);
|
||||
const x = trendX(index, trendRows.value.length);
|
||||
const redY = trendY(red);
|
||||
const warningY = trendY(warning);
|
||||
const durationY = trendY(duration);
|
||||
const rawTime = run.updatedAt || run.createdAt || "";
|
||||
const durationText = runDurationText(run);
|
||||
const configTimingText = runTimingConfigText(run);
|
||||
@@ -185,10 +67,10 @@ createApp({
|
||||
id: run.id || String(index),
|
||||
runId: run.id || "",
|
||||
x,
|
||||
redY,
|
||||
warningY,
|
||||
durationY,
|
||||
duration,
|
||||
tooltipLeft: `${clamp((x / 720) * 100, 16, 84)}%`,
|
||||
tooltipTop: `${clamp(((Math.min(redY, warningY) + 18) / 142) * 100, 24, 76)}%`,
|
||||
tooltipTop: `${clamp(((durationY + 18) / 142) * 100, 24, 76)}%`,
|
||||
red,
|
||||
warning,
|
||||
total,
|
||||
@@ -363,6 +245,10 @@ createApp({
|
||||
return runCheckAlertCount(run);
|
||||
}
|
||||
|
||||
function trendDurationMinutes(run) {
|
||||
return number(run?.runDurationMinutes ?? run?.durationMinutes ?? run?.timing?.durationMinutes);
|
||||
}
|
||||
|
||||
function openCheckDetail(item) {
|
||||
activeCheckItem.value = item || null;
|
||||
}
|
||||
@@ -459,6 +345,7 @@ createApp({
|
||||
selectedRun,
|
||||
trendRows,
|
||||
latestTrendRun,
|
||||
trendDurationMax,
|
||||
trendPolylines,
|
||||
trendDots,
|
||||
timelineRuns,
|
||||
@@ -492,12 +379,14 @@ createApp({
|
||||
trendTotalCount,
|
||||
trendErrorCount,
|
||||
trendWarningCount,
|
||||
trendDurationMinutes,
|
||||
runDurationText,
|
||||
runTimingConfigText,
|
||||
severityClass,
|
||||
formatDate,
|
||||
formatAbsoluteDate,
|
||||
formatDuration,
|
||||
formatMinutes,
|
||||
shortId,
|
||||
rootCauseText,
|
||||
findingTitle,
|
||||
@@ -566,19 +455,17 @@ 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.total" class="trend-total" :points="trendPolylines.total"></polyline>
|
||||
<polyline v-if="trendPolylines.warning" class="trend-warning" :points="trendPolylines.warning"></polyline>
|
||||
<polyline v-if="trendPolylines.red" class="trend-red" :points="trendPolylines.red"></polyline>
|
||||
<polyline v-if="trendPolylines.duration" class="trend-duration" :points="trendPolylines.duration"></polyline>
|
||||
<g
|
||||
v-for="dot in trendDots"
|
||||
:key="dot.id"
|
||||
@@ -591,11 +478,8 @@ createApp({
|
||||
@mouseleave="hideTrendTooltip"
|
||||
@focusout="hideTrendTooltip"
|
||||
>
|
||||
<circle class="trend-hit-area" :cx="dot.x" :cy="Math.min(dot.redY, dot.warningY)" r="12"></circle>
|
||||
<circle class="trend-dot-warning" :cx="dot.x" :cy="dot.warningY" r="3">
|
||||
<title>{{ dot.title }}</title>
|
||||
</circle>
|
||||
<circle class="trend-dot-red" :cx="dot.x" :cy="dot.redY" r="3">
|
||||
<circle class="trend-hit-area" :cx="dot.x" :cy="dot.durationY" r="12"></circle>
|
||||
<circle class="trend-dot-duration" :cx="dot.x" :cy="dot.durationY" r="4">
|
||||
<title>{{ dot.title }}</title>
|
||||
</circle>
|
||||
</g>
|
||||
@@ -617,9 +501,9 @@ createApp({
|
||||
<div v-if="trendRows.length === 0" class="trend-empty">暂无运行数据</div>
|
||||
</div>
|
||||
<div class="trend-legend">
|
||||
<span class="legend-item"><span class="legend-swatch red"></span>最新点错误 {{ trendErrorCount(latestTrendRun) }}</span>
|
||||
<span class="legend-item"><span class="legend-swatch warning"></span>最新点告警 {{ trendWarningCount(latestTrendRun) }}</span>
|
||||
<span class="legend-item"><span class="legend-swatch total"></span>最新点错误+告警合计 {{ trendTotalCount(latestTrendRun) }}</span>
|
||||
<span class="legend-item"><span class="legend-swatch duration"></span>最新运行耗时 {{ runDurationText(latestTrendRun) }}</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>
|
||||
@@ -1127,7 +1011,7 @@ function clamp(value, min, max) {
|
||||
function rootCauseText(item) {
|
||||
const display = checkDisplay(item);
|
||||
if (display.summary) return display.summary;
|
||||
const safeSummary = safeUserText(item?.checkSummaryZh || item?.summary || item?.evidenceSummary);
|
||||
const safeSummary = safeUserText(item?.checkSummaryZh || item?.check?.summaryZh);
|
||||
if (safeSummary) return safeSummary;
|
||||
if (item?.rootCause) return "已记录内部根因,见报告详情。";
|
||||
return "尚未记录用户可见问题摘要。";
|
||||
@@ -1201,7 +1085,8 @@ function checkTimeText(item) {
|
||||
}
|
||||
|
||||
function checkActionText(item) {
|
||||
return safeUserText(item?.nextAction || item?.action || item?.recommendation) || "查看详情后处理";
|
||||
const display = checkDisplay(item);
|
||||
return display.action || safeUserText(item?.checkActionZh || item?.check?.actionZh) || "查看详情后处理";
|
||||
}
|
||||
|
||||
function checkDetailRows(item) {
|
||||
@@ -1223,7 +1108,7 @@ function checkEvidenceRows(item) {
|
||||
if (!item) return [{ key: "empty", label: "状态", value: "未选择监测项" }];
|
||||
const evidence = item?.evidence && typeof item.evidence === "object" && !Array.isArray(item.evidence) ? item.evidence : {};
|
||||
const rows = [
|
||||
{ key: "summary", label: "证据摘要", value: safeUserText(item?.evidenceSummary || evidence.summary || item?.summary) || rootCauseText(item) },
|
||||
{ key: "summary", label: "证据摘要", value: rootCauseText(item) || safeUserText(item?.evidenceSummary || evidence.summary) },
|
||||
{ key: "sample", label: "样本序号", value: safeDetailValue(item?.sampleSeq ?? evidence.sampleSeq) },
|
||||
{ key: "page", label: "页面", value: safeDetailValue(item?.pageRole || evidence.pageRole) },
|
||||
{ key: "command", label: "命令编号", value: safeDetailValue(item?.commandId || evidence.commandId) },
|
||||
@@ -1254,20 +1139,12 @@ function safeDetailValue(value) {
|
||||
function checkDisplay(item) {
|
||||
const rawCode = rawCheckCode(item);
|
||||
const rawId = rawFindingId(item);
|
||||
const registered = checkDisplayCatalog[rawId] || checkDisplayCatalog[rawCode];
|
||||
const serviceCode = publicCheckCode(item?.checkCode || item?.check?.code);
|
||||
if (registered) {
|
||||
return {
|
||||
...registered,
|
||||
code: serviceCode || registered.code,
|
||||
title: safeUserText(item?.checkTitleZh || item?.check?.titleZh) || registered.title,
|
||||
summary: safeUserText(item?.checkSummaryZh || item?.summary || item?.evidenceSummary || item?.check?.summaryZh) || registered.summary,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: serviceCode || publicCheckCode(rawId || rawCode) || stableCheckCode(rawCode || rawId),
|
||||
title: safeUserText(item?.checkTitleZh || item?.check?.titleZh) || "未登记监测项",
|
||||
summary: safeUserText(item?.checkSummaryZh || item?.summary || item?.evidenceSummary) || "已记录监测项详情,见报告原文。",
|
||||
summary: safeUserText(item?.checkSummaryZh || item?.check?.summaryZh) || "已记录监测项详情,见报告原文。",
|
||||
action: safeUserText(item?.checkActionZh || item?.check?.actionZh) || "查看详情后处理",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
|
||||
// Responsibility: Redacted YAML configRef graph for web-probe sentinel plan/status.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { rootPath } from "./config";
|
||||
import { readWebProbeSentinelConfigRef } from "./hwlab-node-web-sentinel-config-ref";
|
||||
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes";
|
||||
import { effectiveWebProbeSentinelPublicExposure, resolveWebProbeSentinel, webProbeSentinelRegistryRows, type WebProbeSentinelRegistryRow } from "./hwlab-node-web-sentinel-resolver";
|
||||
@@ -100,7 +102,7 @@ const REQUIRED_TARGET_SHAPES: Record<HwlabRuntimeWebProbeSentinelConfigRefKey, R
|
||||
},
|
||||
reportViews: {
|
||||
kind: "object",
|
||||
requiredPaths: ["defaultView", "views[0]", "pageSize", "maxPageSize", "rawAccess", "redaction.prompt", "redaction.secrets"],
|
||||
requiredPaths: ["defaultView", "views[0]", "pageSize", "maxPageSize", "rawAccess", "checkCatalogRef", "redaction.prompt", "redaction.secrets"],
|
||||
},
|
||||
publicExposure: {
|
||||
kind: "object",
|
||||
@@ -320,6 +322,7 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
|
||||
const runtime = recordTarget(byKey.get("runtime"));
|
||||
const scenarios = scenarioTargets(byKey.get("scenarios"));
|
||||
const promptSet = recordTarget(byKey.get("promptSet"));
|
||||
const reportViews = recordTarget(byKey.get("reportViews"));
|
||||
const cicd = recordTarget(byKey.get("cicd"));
|
||||
const secrets = recordTarget(byKey.get("secrets"));
|
||||
|
||||
@@ -350,6 +353,8 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
|
||||
conflicts.push(`${byKey.get("cicd")?.file}#${byKey.get("cicd")?.path}.targetValidation.scenarioId=${validationScenarioId} is not declared in scenarios`);
|
||||
}
|
||||
|
||||
validateReportViewsCheckCatalog(spec, conflicts, byKey.get("reportViews"), reportViews);
|
||||
|
||||
const runtimeNamespace = runtime === null ? null : stringAt(runtime, "namespace");
|
||||
const runtimeSecrets = secrets === null ? [] : arrayAt(secrets, "runtimeSecrets");
|
||||
if (runtimeNamespace !== null) {
|
||||
@@ -363,6 +368,74 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function validateReportViewsCheckCatalog(spec: HwlabRuntimeLaneSpec, conflicts: string[], ref: InternalConfigRefStatus | undefined, reportViews: Record<string, unknown> | null): void {
|
||||
if (reportViews === null) return;
|
||||
const catalogRef = stringAt(reportViews, "checkCatalogRef");
|
||||
if (catalogRef === null) {
|
||||
conflicts.push(`${ref?.file ?? "-"}#${ref?.path ?? "-"}.checkCatalogRef is required`);
|
||||
return;
|
||||
}
|
||||
let catalog: Record<string, unknown>;
|
||||
try {
|
||||
const target = readWebProbeSentinelConfigRef(spec, catalogRef).target;
|
||||
catalog = isRecord(target) ? target : {};
|
||||
} catch (error) {
|
||||
conflicts.push(`${ref?.file ?? "-"}#${ref?.path ?? "-"}.checkCatalogRef=${catalogRef} is not readable: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
const items = arrayAt(catalog, "items");
|
||||
if (items.length === 0) {
|
||||
conflicts.push(`${catalogRef}.items is empty`);
|
||||
return;
|
||||
}
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
const codes = new Set<string>();
|
||||
for (const [index, item] of items.entries()) {
|
||||
const itemId = stringAt(item, "id");
|
||||
const code = stringAt(item, "code");
|
||||
if (itemId === null) conflicts.push(`${catalogRef}.items[${index}].id is required`);
|
||||
else if (byId.has(itemId)) conflicts.push(`${catalogRef}.items id duplicated: ${itemId}`);
|
||||
else byId.set(itemId, item);
|
||||
if (code === null) conflicts.push(`${catalogRef}.items[${index}].code is required`);
|
||||
else if (codes.has(code)) conflicts.push(`${catalogRef}.items code duplicated: ${code}`);
|
||||
else codes.add(code);
|
||||
for (const field of ["titleZh", "summaryZh", "actionZh"]) {
|
||||
const text = stringAt(item, field);
|
||||
if (text === null) {
|
||||
conflicts.push(`${catalogRef}.items[${index}].${field} is required`);
|
||||
} else if (!containsChinese(text)) {
|
||||
conflicts.push(`${catalogRef}.items[${index}].${field} must contain Chinese text`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const requiredIds = knownWebProbeFindingIds();
|
||||
const missing = requiredIds.filter((id) => !byId.has(id));
|
||||
if (missing.length > 0) {
|
||||
conflicts.push(`${catalogRef}.items missing analyzer finding ids: ${missing.slice(0, 24).join(",")}${missing.length > 24 ? `,+${missing.length - 24}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
function knownWebProbeFindingIds(): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const file of [
|
||||
"scripts/src/hwlab-node-web-observe-analyzer-source.ts",
|
||||
"scripts/src/hwlab-node-web-sentinel-p5-observe.ts",
|
||||
]) {
|
||||
let source = "";
|
||||
try {
|
||||
source = readFileSync(rootPath(file), "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const match of source.matchAll(/\bid:\s*["`]([A-Za-z0-9_.:-]+)["`]/gu)) ids.add(match[1]);
|
||||
}
|
||||
return Array.from(ids).sort();
|
||||
}
|
||||
|
||||
function containsChinese(value: string): boolean {
|
||||
return /[\u3400-\u9fff]/u.test(value);
|
||||
}
|
||||
|
||||
function requireReadableConfigRef(spec: HwlabRuntimeLaneSpec, conflicts: string[], ref: InternalConfigRefStatus | undefined, path: string, value: unknown): void {
|
||||
if (typeof value !== "string" || value.length === 0) return;
|
||||
try {
|
||||
|
||||
@@ -552,7 +552,7 @@ await page.evaluate(() => {
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const trendHoverPoint = await page.evaluate(() => {
|
||||
const target = document.querySelector(".trend-dot-hit .trend-dot-red") || document.querySelector(".trend-dot-hit .trend-dot-warning");
|
||||
const target = document.querySelector(".trend-dot-hit .trend-dot-duration");
|
||||
if (!(target instanceof SVGElement)) return null;
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
@@ -600,17 +600,15 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
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+)/u.exec(row);
|
||||
const match = /(\d+(?:\.\d+)?)/u.exec(row);
|
||||
return match ? Number(match[1]) : null;
|
||||
};
|
||||
const chartCounts = {
|
||||
error: legendNumber("最新点错误"),
|
||||
warning: legendNumber("最新点告警"),
|
||||
total: legendNumber("错误+告警合计"),
|
||||
const chartTiming = {
|
||||
latestMinutes: legendNumber("最新运行耗时"),
|
||||
maxMinutes: legendNumber("最近最高耗时"),
|
||||
title: text("#trend-heading"),
|
||||
legendHasDuration: legendTexts.some((item) => item.includes("最新运行耗时")),
|
||||
};
|
||||
chartCounts.ok = typeof chartCounts.error === "number" && typeof chartCounts.warning === "number" && typeof chartCounts.total === "number"
|
||||
? chartCounts.total === chartCounts.error + chartCounts.warning
|
||||
: false;
|
||||
const apiUrl = (path) => {
|
||||
const basePath = root?.getAttribute("data-base-path") || expectedRoutePrefix || "";
|
||||
const prefix = basePath.replace(/\/+$/u, "");
|
||||
@@ -769,10 +767,9 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
total: errorSampleCount(overviewCounts) + warningSampleCount(overviewCounts),
|
||||
allSamples: allSampleCount(overviewCounts),
|
||||
};
|
||||
chartCounts.matchesLatestRun = chartCounts.ok === true
|
||||
&& chartCounts.error === latestRunCounts.error
|
||||
&& chartCounts.warning === latestRunCounts.warning
|
||||
&& chartCounts.total === latestRunCounts.total;
|
||||
chartTiming.ok = chartTiming.title.includes("运行耗时") && chartTiming.legendHasDuration === true && Number.isFinite(chartTiming.latestMinutes);
|
||||
chartTiming.matchesLatestRun = chartTiming.ok === true
|
||||
&& Math.abs(Number(chartTiming.latestMinutes) - Number(latestRunCounts.durationMinutes)) < 0.02;
|
||||
const trendPanel = document.querySelector(".trend-panel");
|
||||
const trendLegend = document.querySelector(".trend-panel .trend-legend");
|
||||
const trendPanelRect = trendPanel?.getBoundingClientRect();
|
||||
@@ -870,7 +867,7 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
trendDotCount: document.querySelectorAll(".trend-dot-hit").length,
|
||||
trendTooltip: trendTooltipSummary,
|
||||
trendPanelText: text("#trend-heading"),
|
||||
chartCounts,
|
||||
chartTiming,
|
||||
latestRunCounts,
|
||||
latestDetailSummary,
|
||||
timingVisibility,
|
||||
@@ -881,7 +878,7 @@ const dom = await page.evaluate(async ({ expectedRoutePrefix, expectedSentinelId
|
||||
overviewSamples,
|
||||
panelHeights,
|
||||
scopeLabels: {
|
||||
latestPointLegend: legendTexts.some((item) => item.includes("最新点")),
|
||||
latestPointLegend: legendTexts.some((item) => item.includes("最新运行耗时")),
|
||||
historicalSamples: legendTexts.some((item) => item.includes("历史样本累计")) || statusText.includes("历史错误样本"),
|
||||
},
|
||||
timelineItems: document.querySelectorAll(".timeline-list .timeline-item").length,
|
||||
@@ -1067,8 +1064,8 @@ const ok = !navigationError
|
||||
&& dom.sentinelBoundary?.routePrefixMatches === true
|
||||
&& dom.errorVisible !== true
|
||||
&& dom.trendCurve === true
|
||||
&& dom.chartCounts?.ok === true
|
||||
&& dom.chartCounts?.matchesLatestRun === true
|
||||
&& dom.chartTiming?.ok === true
|
||||
&& dom.chartTiming?.matchesLatestRun === true
|
||||
&& dom.checkScope?.matchesLatestRun === true
|
||||
&& dom.checkScope?.matchesRunDetail === true
|
||||
&& dom.selectedRunTags?.matchesRunDetail === true
|
||||
@@ -1533,7 +1530,7 @@ function renderDashboardResult(result: Record<string, unknown>): string {
|
||||
const dom = record(page.dom);
|
||||
const dataset = record(dom.dataset);
|
||||
const layout = record(dom.layout);
|
||||
const chartCounts = record(dom.chartCounts);
|
||||
const chartTiming = record(dom.chartTiming);
|
||||
const latestRunCounts = record(dom.latestRunCounts);
|
||||
const latestDetailSummary = record(dom.latestDetailSummary);
|
||||
const timingVisibility = record(dom.timingVisibility);
|
||||
@@ -1570,12 +1567,11 @@ function renderDashboardResult(result: Record<string, unknown>): string {
|
||||
"",
|
||||
table(["TITLE", "STATUS_TEXT", "CONTRACT", "BASE_PATH"], [[dom.title, dom.statusText, dataset.contractVersion, dataset.basePath ?? "-"]]),
|
||||
"",
|
||||
table(["TREND_ERR_TYPES", "TREND_ALERT_TYPES", "TREND_TOTAL_TYPES", "TREND_EXACT", "MATCH_LATEST", "BAD_TITLE", "BAD_BODY"], [[
|
||||
chartCounts.error ?? "-",
|
||||
chartCounts.warning ?? "-",
|
||||
chartCounts.total ?? "-",
|
||||
chartCounts.ok ?? "-",
|
||||
chartCounts.matchesLatestRun ?? "-",
|
||||
table(["TREND_LATEST_MIN", "TREND_MAX_MIN", "TREND_DURATION", "MATCH_LATEST", "BAD_TITLE", "BAD_BODY"], [[
|
||||
chartTiming.latestMinutes ?? "-",
|
||||
chartTiming.maxMinutes ?? "-",
|
||||
chartTiming.ok ?? "-",
|
||||
chartTiming.matchesLatestRun ?? "-",
|
||||
dom.badCardTitleCount ?? "-",
|
||||
dom.badCardBodyCount ?? "-",
|
||||
]]),
|
||||
|
||||
@@ -1316,6 +1316,7 @@ function enrichFindingWithCheck(config: WebProbeSentinelServiceConfig, value: Re
|
||||
checkLevel,
|
||||
checkTitleZh: stringOrNull(check.titleZh),
|
||||
checkSummaryZh: stringOrNull(check.summaryZh),
|
||||
checkActionZh: stringOrNull(check.actionZh),
|
||||
checkRegistered: check.registered === true,
|
||||
blocking: value.blocking === true || check.blocking === true,
|
||||
valuesRedacted: true,
|
||||
@@ -1327,13 +1328,13 @@ function checkForFinding(config: WebProbeSentinelServiceConfig, value: Record<st
|
||||
const entry = checkCatalogById(config).get(findingId) ?? null;
|
||||
const rawLevel = normalizeCheckLevel(stringOrNull(value.severity) ?? stringOrNull(value.level));
|
||||
if (entry === null) {
|
||||
const summary = stringOrNull(value.summary) ?? stringOrNull(value.message) ?? findingId;
|
||||
return {
|
||||
id: findingId,
|
||||
code: null,
|
||||
level: rawLevel ?? "unknown",
|
||||
titleZh: findingId,
|
||||
summaryZh: summary.slice(0, 160),
|
||||
titleZh: "未登记监测项",
|
||||
summaryZh: "该监测项未在 YAML 中登记,配置校验需要补齐中文说明。",
|
||||
actionZh: "补齐 YAML 监测项说明后重新验证。",
|
||||
blocking: value.blocking === true,
|
||||
order: 10000,
|
||||
registered: false,
|
||||
@@ -1345,7 +1346,8 @@ function checkForFinding(config: WebProbeSentinelServiceConfig, value: Record<st
|
||||
code: stringOrNull(entry.code),
|
||||
level: normalizeCheckLevel(stringOrNull(entry.level)) ?? rawLevel ?? "unknown",
|
||||
titleZh: stringOrNull(entry.titleZh) ?? findingId,
|
||||
summaryZh: stringOrNull(entry.summaryZh) ?? stringOrNull(value.summary) ?? stringOrNull(value.message) ?? findingId,
|
||||
summaryZh: stringOrNull(entry.summaryZh) ?? "已记录监测项详情。",
|
||||
actionZh: stringOrNull(entry.actionZh) ?? "查看详情后处理。",
|
||||
blocking: entry.blocking === true,
|
||||
order: numberOr(entry.order, 10000),
|
||||
registered: true,
|
||||
|
||||
Reference in New Issue
Block a user