fix: make sentinel trend chart show duration
This commit is contained in:
@@ -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