fix: make sentinel trend chart show duration

This commit is contained in:
Codex
2026-07-01 01:23:38 +00:00
parent b41a261ebb
commit 473b4f2300
6 changed files with 487 additions and 208 deletions
+74 -1
View File
@@ -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 {
+20 -24
View File
@@ -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,