diff --git a/config/hwlab-web-probe-sentinel/check-catalog.yaml b/config/hwlab-web-probe-sentinel/check-catalog.yaml
index 75a82ef4..ee61d384 100644
--- a/config/hwlab-web-probe-sentinel/check-catalog.yaml
+++ b/config/hwlab-web-probe-sentinel/check-catalog.yaml
@@ -222,8 +222,8 @@ sentinel:
- code: WBC-026
id: runtime-http-errors
level: warning
- titleZh: 页面请求返回错误
- summaryZh: 页面加载过程中有请求返回错误状态。
+ titleZh: 页面请求返回非 5xx 错误
+ summaryZh: 页面加载过程中有请求返回非 5xx 错误状态。
actionZh: 查看详情后处理。
blocking: false
order: 260
@@ -1003,3 +1003,11 @@ sentinel:
actionZh: 修复 analyzer/report 对 commandFailures 的 compact 输出,先补齐命令证据再判断页面业务状态。
blocking: true
order: 1230
+ - code: WBC-124
+ id: page-response-error
+ level: error
+ titleZh: 页面响应错误
+ summaryZh: 运行页面浏览器网络事件捕获到 HTTP 5xx 响应。
+ actionZh: 按详情中的状态码和路径追溯后端日志/OTel,先修复 5xx 响应。
+ blocking: true
+ order: 1240
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 18d3daa2..f5fcf0f0 100644
--- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css
+++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.css
@@ -986,6 +986,8 @@ select {
display: flex;
flex: 0 0 auto;
align-items: center;
+ flex-wrap: wrap;
+ justify-content: flex-end;
gap: 8px;
}
@@ -999,6 +1001,27 @@ select {
cursor: pointer;
}
+.copy-button {
+ white-space: nowrap;
+}
+
+.copy-status {
+ margin-top: 8px;
+ color: var(--green);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.error-id-pill {
+ display: inline-block;
+ max-width: 100%;
+ margin-left: 8px;
+ overflow-wrap: anywhere;
+ color: var(--muted);
+ font-family: var(--mono);
+ font-size: 12px;
+}
+
.check-dialog-body {
display: grid;
grid-template-columns: minmax(300px, 0.82fr) minmax(420px, 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 11531850..46c09501 100644
--- a/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js
+++ b/scripts/assets/web-probe-sentinel-monitor-web/monitor-web.js
@@ -37,12 +37,14 @@ createApp({
const checkSeverityFilter = ref("alert");
const findingFilter = ref("");
const activeCheckItem = ref(null);
+ const detailCopyState = ref("");
const autoRefresh = ref(true);
const refreshSeconds = ref(30);
const lastLoadedAt = ref("");
const hoveredTrendDot = ref(null);
const deepLinkRunId = ref(initialDeepLink.runId);
const deepLinkFocus = ref(initialDeepLink.focus);
+ let pendingInitialErrorId = initialDeepLink.errorId;
let pendingInitialFocus = initialDeepLink.focus;
let lastAutoRefreshAt = 0;
@@ -279,6 +281,13 @@ createApp({
void refreshRunCheckSummaries(runs.value);
lastLoadedAt.value = new Date().toISOString();
lastAutoRefreshAt = Date.now();
+ if (pendingInitialErrorId) {
+ const errorId = pendingInitialErrorId;
+ pendingInitialErrorId = "";
+ await openErrorById(errorId, { initial: true });
+ pendingInitialFocus = "";
+ return;
+ }
const deepLinkedRun = deepLinkRunId.value ? runs.value.find((run) => run.id === deepLinkRunId.value) : null;
const keepSelected = runs.value.find((run) => run.id === selectedRunId.value);
const nextRun = deepLinkedRun || keepSelected || runs.value[0] || latestRun.value;
@@ -303,7 +312,7 @@ createApp({
deepLinkRunId.value = runId;
const nextFocus = normalizeDeepLinkFocus(options.focus || deepLinkFocus.value || "");
deepLinkFocus.value = nextFocus;
- syncMonitorDeepLink(runId, nextFocus);
+ syncMonitorDeepLink(runId, nextFocus, options.errorId || "");
if (!silent) selectedDetail.value = null;
try {
selectedDetail.value = await fetchJson(`/api/runs/${encodeURIComponent(runId)}`);
@@ -407,12 +416,48 @@ createApp({
return number(run?.runDurationMinutes ?? run?.durationMinutes ?? run?.timing?.durationMinutes);
}
- function openCheckDetail(item) {
+ async function openErrorById(errorId, options = {}) {
+ if (!errorId) return;
+ const payload = await fetchJson(`/api/errors/${encodeURIComponent(errorId)}`);
+ const runId = payload?.run?.id || payload?.detail?.identity?.runId || "";
+ if (runId) await selectRun(runId, true, { focus: "detail", errorId });
+ const rows = Array.isArray(selectedDetail.value?.findings) ? selectedDetail.value.findings : [];
+ const row = rows.find((item) => checkErrorId(item) === errorId) || payload.finding || {};
+ activeCheckItem.value = { ...row, ...(payload.finding || {}), errorId, detail: payload.detail || row.detail || null };
+ deepLinkFocus.value = "detail";
+ if (runId) syncMonitorDeepLink(runId, "detail", errorId);
+ if (options.initial === true) await nextTick();
+ }
+
+ async function openCheckDetail(item) {
activeCheckItem.value = item || null;
+ detailCopyState.value = "";
+ const errorId = checkErrorId(item);
+ const runId = item?.latestRunId || item?.runId || item?.run?.id || selectedRunId.value || "";
+ if (runId) syncMonitorDeepLink(runId, "detail", errorId);
+ if (!errorId) return;
+ try {
+ const payload = await fetchJson(`/api/errors/${encodeURIComponent(errorId)}`);
+ activeCheckItem.value = { ...item, ...(payload.finding || {}), errorId, detail: payload.detail || item.detail || null };
+ } catch (cause) {
+ activeCheckItem.value = { ...item, errorId, detail: { ok: false, error: String(cause?.message || cause), identity: { errorId }, valuesRedacted: true } };
+ }
}
function closeCheckDetail() {
activeCheckItem.value = null;
+ detailCopyState.value = "";
+ if (selectedRunId.value) syncMonitorDeepLink(selectedRunId.value, deepLinkFocus.value === "detail" ? "" : deepLinkFocus.value, "");
+ }
+
+ async function copyErrorId(item) {
+ await copyText(checkErrorId(item));
+ detailCopyState.value = "error-id";
+ }
+
+ async function copyDetailLink(item) {
+ await copyText(checkDeepLink(item));
+ detailCopyState.value = "link";
}
async function refreshHistoricalFindings() {
@@ -554,6 +599,7 @@ createApp({
checkSeverityFilter,
findingFilter,
activeCheckItem,
+ detailCopyState,
autoRefresh,
refreshSeconds,
lastLoadedAt,
@@ -604,6 +650,8 @@ createApp({
selectCheckRun,
openCheckDetail,
closeCheckDetail,
+ copyErrorId,
+ copyDetailLink,
refreshNow,
triggerQuickVerify,
currentHref,
@@ -646,6 +694,10 @@ createApp({
checkActionText,
checkDetailRows,
checkEvidenceRows,
+ checkErrorId,
+ checkDeepLink,
+ checkTraceRows,
+ checkOtelRows,
levelLabel,
findingGroupCountLabel,
timeWindowLabel,
@@ -1116,6 +1168,8 @@ createApp({
role="button"
:aria-label="'查看监测项详情 ' + findingCode(item) + ' ' + findingTitle(item)"
:data-check-code="findingCode(item)"
+ :data-error-id="checkErrorId(item)"
+ :data-check-deep-link="checkDeepLink(item)"
@click="openCheckDetail(item)"
@keydown.enter.prevent="openCheckDetail(item)"
@keydown.space.prevent="openCheckDetail(item)"
@@ -1139,6 +1193,7 @@ createApp({
v-if="activeCheckItem"
class="check-dialog-backdrop"
data-check-dialog="true"
+ :data-error-id="checkErrorId(activeCheckItem)"
@click.self="closeCheckDetail"
@keydown.esc="closeCheckDetail"
>
@@ -1147,9 +1202,14 @@ createApp({
{{ findingCode(activeCheckItem) }}
{{ findingTitle(activeCheckItem) }}
-
{{ checkScopeText }}
+
+ {{ checkScopeText }}
+ {{ checkErrorId(activeCheckItem) }}
+
+
+
{{ levelLabel(activeCheckItem) }} · {{ findingGroupCountLabel(activeCheckItem) }}
@@ -1165,12 +1225,13 @@ createApp({
- 用户影响
+ 详情
{{ rootCauseText(activeCheckItem) }}
处理建议: {{ checkActionText(activeCheckItem) }}
+ {{ detailCopyState === "link" ? "已复制深链" : "已复制错误ID" }}
- 关联证据
+ 哨兵证据
{{ row.label }}
@@ -1178,6 +1239,24 @@ createApp({
+
+ Trace 引用
+
+
+ {{ row.label }}
+ {{ row.value }}
+
+
+
+
+ OTel 追溯
+
+
+ {{ row.label }}
+ {{ row.value }}
+
+
+
@@ -1208,23 +1287,34 @@ function readBootstrap() {
function readMonitorDeepLink() {
const params = new URLSearchParams(window.location.search || "");
const runId = String(params.get("run") || params.get("runId") || "").trim();
+ const errorId = String(params.get("error") || params.get("errorId") || "").trim();
const focus = normalizeDeepLinkFocus(params.get("focus") || params.get("panel") || window.location.hash.replace(/^#/u, ""));
- return { runId, focus };
+ return { runId, focus, errorId };
}
-function syncMonitorDeepLink(runId, focus) {
+function syncMonitorDeepLink(runId, focus, errorId = "") {
if (!window.history?.replaceState) return;
const nextFocus = normalizeDeepLinkFocus(focus);
const url = new URL(window.location.href);
- url.searchParams.set("run", runId);
+ if (runId) url.searchParams.set("run", runId);
+ else url.searchParams.delete("run");
if (nextFocus) url.searchParams.set("focus", nextFocus);
else url.searchParams.delete("focus");
+ const nextErrorId = String(errorId || "").trim();
+ if (nextErrorId) {
+ url.searchParams.set("error", nextErrorId);
+ url.searchParams.set("panel", "detail");
+ } else {
+ url.searchParams.delete("error");
+ url.searchParams.delete("panel");
+ }
window.history.replaceState(null, "", url.toString());
}
function normalizeDeepLinkFocus(value) {
const text = String(value || "").trim().toLowerCase();
if (["memory", "memory-chart", "run-memory", "page-memory"].includes(text)) return "memory";
+ if (["detail", "error", "finding"].includes(text)) return "detail";
return "";
}
@@ -1259,6 +1349,24 @@ function apiUrl(path) {
return `${prefix}${suffix}`;
}
+async function copyText(value) {
+ const text = String(value || "");
+ if (!text) return;
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ const input = document.createElement("textarea");
+ input.value = text;
+ input.setAttribute("readonly", "true");
+ input.style.position = "fixed";
+ input.style.left = "-1000px";
+ document.body.appendChild(input);
+ input.select();
+ document.execCommand("copy");
+ input.remove();
+}
+
function setReady(value) {
document.querySelector("#monitor-web-root")?.setAttribute("data-monitor-ready", value ? "true" : "false");
}
@@ -1559,15 +1667,34 @@ function checkActionText(item) {
return display.action || safeUserText(item?.checkActionZh || item?.check?.actionZh) || "查看详情后处理";
}
+function checkErrorId(item) {
+ return safeDetailValue(item?.errorId || item?.detail?.identity?.errorId) === "-" ? "" : safeDetailValue(item?.errorId || item?.detail?.identity?.errorId);
+}
+
+function checkDeepLink(item) {
+ const direct = item?.detail?.links?.canonicalUrl || item?.detail?.links?.canonicalPath || "";
+ if (direct) return String(direct);
+ const errorId = checkErrorId(item);
+ if (!errorId) return window.location.href;
+ const url = new URL(window.location.href);
+ url.searchParams.set("error", errorId);
+ url.searchParams.set("panel", "detail");
+ const runId = item?.latestRunId || item?.runId || item?.run?.id || item?.detail?.identity?.runId || "";
+ if (runId) url.searchParams.set("run", runId);
+ return url.toString();
+}
+
function checkDetailRows(item) {
if (!item) return [{ key: "empty", label: "状态", value: "未选择监测项" }];
+ const identity = item?.detail?.identity || {};
return [
+ { key: "errorId", label: "错误ID", value: checkErrorId(item) || "-" },
{ key: "code", label: "编号", value: findingCode(item) },
{ key: "level", label: "等级", value: levelLabel(item) },
{ key: "samples", label: "样本", value: findingGroupCountLabel(item) },
- { key: "run", label: "运行记录", value: checkRunText(item) },
+ { key: "run", label: "运行记录", value: safeDetailValue(identity.runId || checkRunText(item)) },
{ key: "time", label: "时间", value: checkTimeText(item) },
- { key: "scenario", label: "场景", value: safeDetailValue(item?.scenarioId || item?.scenario_id) },
+ { key: "scenario", label: "场景", value: safeDetailValue(identity.scenarioId || item?.scenarioId || item?.scenario_id) },
{ key: "observer", label: "观察任务", value: safeDetailValue(item?.observerId || item?.observer_id) },
{ key: "timing", label: "计时来源", value: checkTimingText(item) },
{ key: "report", label: "报告", value: shortHash(item?.reportJsonSha256 || item?.report_json_sha256 || item?.reportSha256 || "") || "-" },
@@ -1577,8 +1704,11 @@ function checkDetailRows(item) {
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 detailEvidence = item?.detail?.sentinelEvidence && typeof item.detail.sentinelEvidence === "object" ? item.detail.sentinelEvidence : {};
const rows = [
- { key: "summary", label: "证据摘要", value: rootCauseText(item) || safeUserText(item?.evidenceSummary || evidence.summary) },
+ { key: "summary", label: "证据摘要", value: safeDetailValue(detailEvidence.evidenceSummary || item?.evidenceSummary || evidence.summary || rootCauseText(item)) },
+ { key: "rootCause", label: "根因", value: safeDetailValue(detailEvidence.rootCause || item?.rootCause) },
+ { key: "rootCauseStatus", label: "根因状态", value: safeDetailValue(detailEvidence.rootCauseStatus || item?.rootCauseStatus) },
{ 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) },
@@ -1589,6 +1719,33 @@ function checkEvidenceRows(item) {
return rows.length > 0 ? rows : [{ key: "none", label: "证据摘要", value: "已记录到报告详情。" }];
}
+function checkTraceRows(item) {
+ const refs = Array.isArray(item?.detail?.traceRefs) ? item.detail.traceRefs : Array.isArray(item?.traceRefs) ? item.traceRefs : [];
+ if (refs.length === 0) return [{ key: "none", label: "状态", value: "未发现 trace 引用" }];
+ return refs.slice(0, 8).map((ref, index) => ({
+ key: `${index}-${ref.traceId || ""}`,
+ label: ref.type || "trace",
+ value: safeDetailValue(ref.traceId),
+ }));
+}
+
+function checkOtelRows(item) {
+ const otel = item?.detail?.otel || {};
+ const summaries = Array.isArray(otel.summaries) ? otel.summaries : [];
+ const rows = [{ key: "status", label: "状态", value: safeDetailValue(otel.status) }];
+ if (summaries.length === 0) return rows;
+ return rows.concat(summaries.slice(0, 6).map((summary, index) => ({
+ key: `otel-${index}`,
+ label: safeDetailValue(summary.traceId || `trace ${index + 1}`),
+ value: [
+ summary.status || "-",
+ summary.spanCount !== undefined ? `spans=${summary.spanCount}` : "",
+ summary.errorSpanCount !== undefined ? `errors=${summary.errorSpanCount}` : "",
+ summary.httpStatus !== undefined ? `http=${summary.httpStatus}` : "",
+ ].filter(Boolean).join(" "),
+ })));
+}
+
function checkTimingText(item) {
const status = item?.timingStatus || "";
const source = item?.timingSourceOfTruth || item?.expectedElapsedSource || item?.evidenceKind || "";
diff --git a/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts
index f0a71e78..60c44bbd 100644
--- a/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts
+++ b/scripts/src/hwlab-node-web-observe-analyzer-findings-source.ts
@@ -279,7 +279,29 @@ export function nodeWebObserveAnalyzerFindingsSource(): string {
samples: traceEventsPageReadIssues.requestFailed.slice(0, 20),
valuesRedacted: true
});
- if ((runtimeAlerts?.summary?.httpErrorCount ?? 0) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned HTTP error status during observation", count: runtimeAlerts.summary.httpErrorCount, groups: runtimeAlerts.networkHttpErrorsByPath.slice(0, 12) });
+ const pageResponseErrorGroups = Array.isArray(runtimeAlerts?.networkPageResponseErrorsByPath)
+ ? runtimeAlerts.networkPageResponseErrorsByPath
+ : (Array.isArray(runtimeAlerts?.networkHttpErrorsByPath) ? runtimeAlerts.networkHttpErrorsByPath.filter((item) => Number(item?.status) >= 500) : []);
+ const nonBlockingHttpErrorGroups = Array.isArray(runtimeAlerts?.networkNonBlockingHttpErrorsByPath)
+ ? runtimeAlerts.networkNonBlockingHttpErrorsByPath
+ : (Array.isArray(runtimeAlerts?.networkHttpErrorsByPath) ? runtimeAlerts.networkHttpErrorsByPath.filter((item) => Number(item?.status) < 500) : []);
+ if ((runtimeAlerts?.summary?.pageResponseErrorCount ?? pageResponseErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0)) > 0) findings.push({
+ id: "page-response-error",
+ severity: "red",
+ summary: "页面响应错误:浏览器在运行页面捕获到 HTTP 5xx 响应,属于阻塞错误。",
+ displayTitleZh: "页面响应错误",
+ errorTitleZh: "页面响应错误",
+ rootCause: "page-response-http-5xx",
+ rootCauseStatus: "confirmed-from-browser-network-response",
+ rootCauseConfidence: "high",
+ nextAction: "按状态码和路径追溯后端日志/OTel;先修复 5xx 响应,不要把它归类为前端渲染或采样抖动。",
+ blocking: true,
+ count: runtimeAlerts?.summary?.pageResponseErrorCount ?? pageResponseErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0),
+ evidenceSummary: pageResponseErrorGroups.slice(0, 4).map((item) => "HTTP " + (item.status ?? "-") + " " + (item.method || "-") + " " + (item.urlPath || "-") + " count=" + (item.count ?? "-")).join("; "),
+ groups: pageResponseErrorGroups.slice(0, 12),
+ valuesRedacted: true
+ });
+ if ((runtimeAlerts?.summary?.nonBlockingHttpErrorCount ?? nonBlockingHttpErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0)) > 0) findings.push({ id: "runtime-http-errors", severity: "amber", summary: "natural page requests returned non-5xx HTTP error status during observation", count: runtimeAlerts?.summary?.nonBlockingHttpErrorCount ?? nonBlockingHttpErrorGroups.reduce((sum, item) => sum + Number(item?.count ?? 0), 0), groups: nonBlockingHttpErrorGroups.slice(0, 12) });
if ((runtimeAlerts?.summary?.significantRequestFailedCount ?? runtimeAlerts?.summary?.requestFailedCount ?? 0) > 0) findings.push({ id: "runtime-requestfailed", severity: "amber", summary: "browser requestfailed events were captured during observation", count: runtimeAlerts.summary.significantRequestFailedCount ?? runtimeAlerts.summary.requestFailedCount, groups: (runtimeAlerts.networkSignificantRequestFailedByPath ?? runtimeAlerts.networkRequestFailedByPath).slice(0, 12) });
if ((runtimeAlerts?.summary?.domDiagnosticSampleCount ?? 0) > 0) findings.push({ id: "runtime-dom-diagnostics", severity: "amber", summary: "diagnostic/error/warning-like text was visible in sampled DOM", count: runtimeAlerts.summary.domDiagnosticSampleCount, groupCount: runtimeAlerts.summary.domDiagnosticGroupCount ?? 0, groups: runtimeAlerts.domDiagnosticsByText.slice(0, 12), samples: runtimeAlerts.domDiagnostics.slice(0, 12) });
if ((runtimeAlerts?.summary?.executionErrorCount ?? 0) > 0) findings.push({ id: "runtime-execution-errors", severity: "red", summary: "Workbench rendered execution failure/error rows during observation", count: runtimeAlerts.summary.executionErrorCount, groups: runtimeAlerts.runtimeExecutionErrorsByCode.slice(0, 12) });
diff --git a/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts
index 62473448..efb28c48 100644
--- a/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts
+++ b/scripts/src/hwlab-node-web-observe-analyzer-request-runtime-source.ts
@@ -440,6 +440,8 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
const httpErrors = naturalNetwork
.filter((item) => item?.type === "response" && Number(item.status) >= 400)
.map((item) => networkAlertEvent(item, promptTimes));
+ const pageResponseErrors = httpErrors.filter((item) => Number(item.status) >= 500);
+ const nonBlockingHttpErrors = httpErrors.filter((item) => Number(item.status) < 500);
const requestFailed = naturalNetwork
.filter((item) => item?.type === "requestfailed")
.map((item) => networkAlertEvent(item, promptTimes));
@@ -586,6 +588,8 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
return {
summary: {
httpErrorCount: httpErrors.length,
+ pageResponseErrorCount: pageResponseErrors.length,
+ nonBlockingHttpErrorCount: nonBlockingHttpErrors.length,
requestFailedCount: requestFailed.length,
significantRequestFailedCount: significantRequestFailed.length,
workbenchSessionListReadCount,
@@ -606,6 +610,7 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
significantConsoleAlertCount: significantConsoleAlerts.length,
pageErrorCount: pageErrors.length,
networkErrorGroupCount: groupNetworkAlerts(httpErrors).length,
+ pageResponseErrorGroupCount: groupNetworkAlerts(pageResponseErrors).length,
requestFailedGroupCount: groupNetworkAlerts(requestFailed).length,
significantRequestFailedGroupCount: groupNetworkAlerts(significantRequestFailed).length,
executionErrorGroupCount: groupExecutionErrors(executionErrors).length,
@@ -614,6 +619,8 @@ function buildRuntimeAlerts(samples, control, network, consoleEvents, errors) {
significantConsoleAlertGroupCount: groupConsoleAlerts(significantConsoleAlerts).length
},
networkHttpErrorsByPath: groupNetworkAlerts(httpErrors),
+ networkPageResponseErrorsByPath: groupNetworkAlerts(pageResponseErrors),
+ networkNonBlockingHttpErrorsByPath: groupNetworkAlerts(nonBlockingHttpErrors),
networkRequestFailedByPath: groupNetworkAlerts(requestFailed),
networkSignificantRequestFailedByPath: groupNetworkAlerts(significantRequestFailed),
webPerformancePayloadStates,
diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts
index 5c09b92c..04638dfe 100644
--- a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts
+++ b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts
@@ -115,6 +115,17 @@ export type WebProbeSentinelOptions =
readonly full: boolean;
readonly timeoutSeconds: number;
}
+ | {
+ readonly kind: "error";
+ readonly action: "get";
+ readonly node: string;
+ readonly lane: string;
+ readonly sentinelId: string | null;
+ readonly errorId: string;
+ readonly raw: boolean;
+ readonly full: boolean;
+ readonly timeoutSeconds: number;
+ }
| {
readonly kind: "dashboard";
readonly action: WebProbeSentinelDashboardAction;
diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts
index d51d7aa8..c88756ad 100644
--- a/scripts/src/hwlab-node-web-sentinel-cicd.ts
+++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts
@@ -22,7 +22,7 @@ import { readWebProbeSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-c
import { effectiveWebProbeSentinelPublicExposure, requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import type { RenderedCliResult } from "./output";
-import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5";
+import { probeSentinelRuntimeHealthEndpoint, runSentinelDashboard, runSentinelErrorDetail, runSentinelMaintenance, runSentinelReport, runSentinelValidate } from "./hwlab-node-web-sentinel-p5";
import { runChildCli, sentinelP5Next } from "./hwlab-node-web-sentinel-p5-observe";
import { emitWebProbeSentinelSpan, webProbeSentinelOtelSummary } from "./hwlab-node-web-sentinel-otel";
import {
@@ -159,6 +159,7 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options:
if (options.kind === "maintenance") return runSentinelMaintenance(state, options);
if (options.kind === "validate") return runSentinelValidate(state, options);
if (options.kind === "dashboard") return runSentinelDashboard(state, options);
+ if (options.kind === "error") return runSentinelErrorDetail(state, options);
return runSentinelReport(state, options);
}
diff --git a/scripts/src/hwlab-node-web-sentinel-error-detail.ts b/scripts/src/hwlab-node-web-sentinel-error-detail.ts
new file mode 100644
index 00000000..795745ef
--- /dev/null
+++ b/scripts/src/hwlab-node-web-sentinel-error-detail.ts
@@ -0,0 +1,267 @@
+// SPEC: PJ2026-01060508 Web哨兵 issue-1569 error detail deep links.
+// Responsibility: Stable monitor-web error ids, bounded error detail payloads, and server-side OTel enrichment.
+import { createHash } from "node:crypto";
+import type { Database } from "bun:sqlite";
+import type { WebProbeSentinelServiceConfig } from "./hwlab-node-web-sentinel-service";
+
+export interface WebProbeSentinelErrorReaders {
+ readonly findingsForRow: (row: Record, limit: number) => readonly Record[];
+ readonly runSummary: (row: Record) => Record;
+ readonly traceability: (row: Record) => Record;
+}
+
+export function decorateRunFindingsWithErrorDetails(config: WebProbeSentinelServiceConfig, row: Record, findings: readonly Record[]): Record[] {
+ return findings.map((finding, index) => {
+ const detail = buildErrorDetail(config, row, finding, index, false);
+ return {
+ ...finding,
+ errorId: detail.identity.errorId,
+ detail,
+ valuesRedacted: true,
+ };
+ });
+}
+
+export async function dashboardErrorDetail(config: WebProbeSentinelServiceConfig, db: Database, errorId: string, readers: WebProbeSentinelErrorReaders): Promise> {
+ if (!/^wbe_[a-f0-9]{20}$/u.test(errorId)) return { ok: false, error: "invalid-error-id", errorId, valuesRedacted: true };
+ const rows = db.query("SELECT * FROM runs WHERE sentinel_id = ? AND node = ? AND lane = ? ORDER BY created_at DESC LIMIT ?")
+ .all(config.sentinelId, config.node, config.lane, 200) as Record[];
+ for (const row of rows) {
+ const findings = decorateRunFindingsWithErrorDetails(config, row, readers.findingsForRow(row, 200));
+ const findingIndex = findings.findIndex((item) => stringOrNull(item.errorId) === errorId);
+ if (findingIndex < 0) continue;
+ const finding = findings[findingIndex] ?? {};
+ const detail = buildErrorDetail(config, row, finding, findingIndex, true);
+ const traceRefs = arrayRecords(detail.traceRefs);
+ return {
+ ok: true,
+ errorId,
+ run: readers.runSummary(row),
+ finding,
+ detail: {
+ ...detail,
+ otel: await buildOtelSummary(traceRefs),
+ },
+ traceability: readers.traceability(row),
+ valuesRedacted: true,
+ };
+ }
+ return {
+ ok: false,
+ error: "error-not-found",
+ errorId,
+ searchedRunCount: rows.length,
+ valuesRedacted: true,
+ };
+}
+
+function buildErrorDetail(config: WebProbeSentinelServiceConfig, row: Record, finding: Record, occurrenceIndex: number, includeEvidence: boolean): Record {
+ const identity = errorIdentity(config, row, finding, occurrenceIndex);
+ const traceRefs = extractTraceRefs(finding);
+ return {
+ identity,
+ sentinelEvidence: includeEvidence ? compactSentinelEvidence(finding) : compactSentinelEvidenceSummary(finding),
+ traceRefs,
+ otel: traceRefs.length === 0 ? { status: "trace-ref-missing", summaries: [], valuesRedacted: true } : { status: "detail-api-required", summaries: [], valuesRedacted: true },
+ links: errorLinks(config, identity.errorId),
+ valuesRedacted: true,
+ };
+}
+
+function errorIdentity(config: WebProbeSentinelServiceConfig, row: Record, finding: Record, occurrenceIndex: number): Record {
+ const runId = stringOrNull(row.id) ?? stringOrNull(finding.runId) ?? stringOrNull(finding.latestRunId) ?? "run";
+ const findingId = stringOrNull(finding.findingId) ?? stringOrNull(finding.finding_id) ?? stringOrNull(finding.id) ?? stringOrNull(finding.kind) ?? stringOrNull(finding.code) ?? "finding";
+ const severity = stringOrNull(finding.severity) ?? stringOrNull(finding.level) ?? stringOrNull(finding.checkLevel) ?? "unknown";
+ const sampleSeq = stringOrNull(finding.sampleSeq) ?? stringOrNull(record(finding.evidence).sampleSeq);
+ const commandId = stringOrNull(finding.commandId) ?? stringOrNull(record(finding.firstCommandFailure).commandId);
+ const groupHash = sha256(JSON.stringify({
+ summary: stringOrNull(finding.summary),
+ evidenceSummary: stringOrNull(finding.evidenceSummary),
+ groups: arrayRecords(finding.groups).slice(0, 4),
+ peaks: arrayRecords(finding.peaks).slice(0, 4),
+ })).slice(0, 16);
+ const stableInput = { node: config.node, lane: config.lane, sentinelId: config.sentinelId, runId, findingId, severity, sampleSeq, commandId, occurrenceIndex, groupHash };
+ return {
+ errorId: `wbe_${sha256(JSON.stringify(stableInput)).slice(0, 20)}`,
+ node: config.node,
+ lane: config.lane,
+ sentinelId: config.sentinelId,
+ runId,
+ scenarioId: stringOrNull(row.scenario_id) ?? stringOrNull(finding.scenarioId),
+ findingId,
+ severity,
+ occurrenceIndex,
+ sampleSeq,
+ commandId,
+ groupHash,
+ valuesRedacted: true,
+ };
+}
+
+function compactSentinelEvidenceSummary(finding: Record): Record {
+ return {
+ summary: stringOrNull(finding.summary),
+ displayTitleZh: stringOrNull(finding.displayTitleZh) ?? stringOrNull(finding.errorTitleZh),
+ evidenceSummary: stringOrNull(finding.evidenceSummary),
+ rootCause: stringOrNull(finding.rootCause),
+ rootCauseStatus: stringOrNull(finding.rootCauseStatus),
+ rootCauseConfidence: stringOrNull(finding.rootCauseConfidence),
+ nextAction: stringOrNull(finding.nextAction),
+ blocking: finding.blocking === true,
+ valuesRedacted: true,
+ };
+}
+
+function compactSentinelEvidence(finding: Record): Record {
+ return {
+ ...compactSentinelEvidenceSummary(finding),
+ firstCommandFailure: compactRecord(record(finding.firstCommandFailure), ["commandId", "type", "phase", "status", "resultOk", "failureKind", "failedReason", "message", "exitCode", "timedOut", "artifactBucket", "artifactRef", "failedAt", "completedAt", "abandonedAt"]),
+ rootCauseSignals: compactRootCauseSignals(finding.rootCauseSignals),
+ browserNetworkErrors: arrayRecords(finding.groups).slice(0, 12).map((item) => compactRecord(item, ["method", "urlPath", "status", "type", "count", "firstAt", "lastAt", "promptIndexes", "failureKinds"])),
+ samples: arrayRecords(finding.samples).slice(0, 8),
+ peaks: arrayRecords(finding.peaks).slice(0, 8),
+ valuesRedacted: true,
+ };
+}
+
+async function buildOtelSummary(traceRefs: readonly Record[]): Promise> {
+ if (traceRefs.length === 0) return { status: "trace-ref-missing", summaries: [], valuesRedacted: true };
+ const summaries = [];
+ for (const ref of traceRefs.slice(0, 4)) {
+ const traceId = stringOrNull(ref.traceId);
+ if (traceId === null) continue;
+ if (ref.type !== "otel") {
+ summaries.push({
+ traceId,
+ status: "business-trace-id-not-tempo-id",
+ note: "This id is not a 32 hex OTel trace id; use the linked CLI drill-down to map business trace ids first.",
+ command: `bun scripts/cli.ts platform-infra observability diagnose-code-agent --trace-id ${traceId}`,
+ valuesRedacted: true,
+ });
+ continue;
+ }
+ summaries.push(await queryTempoTrace(traceId));
+ }
+ return {
+ status: summaries.some((item) => item.status === "queried") ? "queried" : "not-queried",
+ summaries,
+ valuesRedacted: true,
+ };
+}
+
+async function queryTempoTrace(traceId: string): Promise> {
+ const baseUrl = String(process.env.UNIDESK_WEB_PROBE_SENTINEL_TEMPO_BASE_URL || process.env.UNIDESK_OBSERVABILITY_TEMPO_BASE_URL || "http://tempo.platform-infra.svc.cluster.local:3200").replace(/\/$/u, "");
+ const url = `${baseUrl}/api/traces/${encodeURIComponent(traceId)}`;
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(2500), headers: { accept: "application/json" } });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) return { traceId, status: "query-failed", httpStatus: response.status, backend: "tempo", valuesRedacted: true };
+ const spans = tempoSpans(payload);
+ return {
+ traceId,
+ status: "queried",
+ httpStatus: response.status,
+ backend: "tempo",
+ spanCount: spans.length,
+ errorSpanCount: spans.filter((span) => span.error).length,
+ services: [...new Set(spans.map((span) => span.service).filter(Boolean))].slice(0, 12),
+ keySpans: spans.filter((span) => span.error || span.httpStatus !== null).slice(0, 8),
+ valuesRedacted: true,
+ };
+ } catch (error) {
+ return { traceId, status: "query-failed", backend: "tempo", error: error instanceof Error ? error.message.slice(0, 180) : String(error).slice(0, 180), valuesRedacted: true };
+ }
+}
+
+function tempoSpans(payload: unknown): Record[] {
+ const batches = arrayRecords(record(payload).batches ?? record(record(payload).data).batches);
+ const spans: Record[] = [];
+ for (const batch of batches) {
+ const service = stringOrNull(record(record(batch.resource).attributes?.find?.((item: unknown) => record(item).key === "service.name")).value?.stringValue);
+ for (const scope of arrayRecords(batch.scopeSpans ?? batch.instrumentationLibrarySpans)) {
+ for (const span of arrayRecords(scope.spans)) {
+ const attrs = spanAttributes(span.attributes);
+ const httpStatus = numberOrNull(attrs["http.response.status_code"]) ?? numberOrNull(attrs["http.status_code"]);
+ spans.push({
+ name: stringOrNull(span.name),
+ service,
+ statusCode: stringOrNull(record(span.status).code),
+ statusMessage: stringOrNull(record(span.status).message),
+ httpRoute: stringOrNull(attrs["http.route"]) ?? stringOrNull(attrs["url.path"]),
+ httpStatus,
+ error: stringOrNull(record(span.status).code) === "STATUS_CODE_ERROR" || Number(httpStatus) >= 500,
+ valuesRedacted: true,
+ });
+ }
+ }
+ }
+ return spans;
+}
+
+function spanAttributes(value: unknown): Record {
+ const output: Record = {};
+ for (const item of arrayRecords(value)) {
+ const key = stringOrNull(item.key);
+ if (key === null) continue;
+ const raw = record(item.value);
+ output[key] = raw.stringValue ?? raw.intValue ?? raw.doubleValue ?? raw.boolValue ?? null;
+ }
+ return output;
+}
+
+function extractTraceRefs(value: unknown): Record[] {
+ const text = JSON.stringify(value ?? {}).slice(0, 24000);
+ const refs = new Map>();
+ for (const match of text.matchAll(/\b[0-9a-fA-F]{32}\b/gu)) refs.set(match[0].toLowerCase(), { traceId: match[0].toLowerCase(), type: "otel", source: "sentinel-finding", valuesRedacted: true });
+ for (const match of text.matchAll(/\btrc_[A-Za-z0-9_-]{8,}\b/gu)) refs.set(match[0], { traceId: match[0], type: "business", source: "sentinel-finding", valuesRedacted: true });
+ return Array.from(refs.values()).slice(0, 12);
+}
+
+function errorLinks(config: WebProbeSentinelServiceConfig, errorId: string): Record {
+ const base = stringOrNull(config.publicExposure.publicBaseUrl) ?? stringOrNull(config.publicExposure.origin) ?? "";
+ const path = `/monitor-web?error=${encodeURIComponent(errorId)}&panel=detail`;
+ return {
+ canonicalPath: path,
+ canonicalUrl: base ? `${base.replace(/\/$/u, "")}${path}` : path,
+ apiPath: `/api/errors/${encodeURIComponent(errorId)}`,
+ cli: `bun scripts/cli.ts web-probe sentinel error get --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --error-id ${errorId}`,
+ valuesRedacted: true,
+ };
+}
+
+function compactRootCauseSignals(value: unknown): Record | null {
+ const source = record(value);
+ if (Object.keys(source).length === 0) return null;
+ return compactRecord(source, ["sessionListReadCount", "traceEventsReadCount", "webPerformanceBeaconFailureCount", "eventSourceFailureCount", "requestFailedCount", "httpErrorCount", "requestfailedTop", "httpStatusTop", "topHttpErrorPaths", "topRequestFailedPaths"]);
+}
+
+function compactRecord(source: Record, keys: readonly string[]): Record | null {
+ const output: Record = {};
+ for (const key of keys) {
+ const value = source[key];
+ if (value === null || value === undefined) continue;
+ output[key] = Array.isArray(value) ? value.slice(0, 8) : typeof value === "string" ? value.slice(0, 500) : value;
+ }
+ return Object.keys(output).length === 0 ? null : { ...output, valuesRedacted: true };
+}
+
+function record(value: unknown): Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
+}
+
+function arrayRecords(value: unknown): Record[] {
+ return Array.isArray(value) ? value.map(record).filter((item) => Object.keys(item).length > 0) : [];
+}
+
+function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function numberOrNull(value: unknown): number | null {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+}
+
+function sha256(value: string): string {
+ return createHash("sha256").update(value).digest("hex");
+}
diff --git a/scripts/src/hwlab-node-web-sentinel-p5.ts b/scripts/src/hwlab-node-web-sentinel-p5.ts
index cb74de50..7256175c 100644
--- a/scripts/src/hwlab-node-web-sentinel-p5.ts
+++ b/scripts/src/hwlab-node-web-sentinel-p5.ts
@@ -254,6 +254,15 @@ export function runSentinelReport(state: SentinelCicdState, options: Extract): RenderedCliResult {
+ const command = "web-probe sentinel error get";
+ const response = callSentinelService(state, "GET", `/api/errors/${encodeURIComponent(options.errorId)}`, null, options.timeoutSeconds);
+ const body = record(response.bodyJson);
+ const rawPayload = Object.keys(body).length > 0 ? body : response;
+ if (options.full || options.raw) return rendered(response.ok && body.ok !== false, command, JSON.stringify(rawPayload, null, 2));
+ return rendered(response.ok && body.ok !== false, command, renderSentinelErrorDetailResult({ command, node: state.spec.nodeId, lane: state.spec.lane, sentinelId: state.sentinelId, response, body, valuesRedacted: true }));
+}
+
function compactSentinelReportRawPayload(
state: SentinelCicdState,
body: Record,
@@ -580,6 +589,8 @@ function compactSentinelReportFinding(value: Record): Record): string {
" report reads sentinel indexed analyze summaries/views only; it does not resample, rerun analyze, or read Workbench.",
].join("\n");
}
+
+function renderSentinelErrorDetailResult(result: Record): string {
+ const body = record(result.body);
+ const detail = record(body.detail);
+ const identity = record(detail.identity);
+ const evidence = record(detail.sentinelEvidence);
+ const otel = record(detail.otel);
+ const finding = record(body.finding);
+ const links = record(detail.links);
+ return [
+ String(result.command),
+ "",
+ table(["NODE", "LANE", "STATUS", "ERROR_ID", "RUN", "FINDING"], [[
+ result.node,
+ result.lane,
+ body.ok === false ? "blocked" : "ok",
+ body.errorId ?? identity.errorId ?? "-",
+ record(body.run).id ?? identity.runId ?? "-",
+ identity.findingId ?? finding.id ?? "-",
+ ]]),
+ "",
+ table(["LEVEL", "BLOCKING", "TITLE", "SUMMARY"], [[
+ identity.severity ?? finding.severity ?? "-",
+ evidence.blocking === true ? "yes" : "no",
+ evidence.displayTitleZh ?? finding.displayTitleZh ?? "-",
+ short(evidence.summary ?? finding.summary ?? "-"),
+ ]]),
+ "",
+ table(["OTEL_STATUS", "TRACE_REFS", "API", "LINK"], [[
+ otel.status ?? "-",
+ Array.isArray(detail.traceRefs) ? detail.traceRefs.length : 0,
+ links.apiPath ?? "-",
+ links.canonicalPath ?? "-",
+ ]]),
+ "",
+ "EVIDENCE",
+ ` rootCause=${evidence.rootCause ?? "-"} status=${evidence.rootCauseStatus ?? "-"}`,
+ ` evidence=${evidence.evidenceSummary ?? "-"}`,
+ "",
+ "NEXT",
+ ` json: bun scripts/cli.ts web-probe sentinel error get --node ${result.node} --lane ${result.lane} --sentinel ${result.sentinelId} --error-id ${body.errorId ?? identity.errorId ?? "-"} --raw`,
+ "",
+ "DISCLOSURE",
+ " error get reads one bounded sentinel error detail by stable errorId and performs backend-controlled OTel lookup when an OTel trace id is present.",
+ ].join("\n");
+}
diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts
index 80aa8f64..335e0959 100644
--- a/scripts/src/hwlab-node-web-sentinel-service.ts
+++ b/scripts/src/hwlab-node-web-sentinel-service.ts
@@ -17,6 +17,7 @@ import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./h
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import { effectiveWebProbeSentinelPublicExposure, resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver";
import { emitWebProbeSentinelSpan, webProbeSentinelOtelSummary } from "./hwlab-node-web-sentinel-otel";
+import { dashboardErrorDetail, decorateRunFindingsWithErrorDetails } from "./hwlab-node-web-sentinel-error-detail";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-27-p11-monitor-web-observability-dashboard";
const DASHBOARD_MAX_TEXT_BYTES = 16_000;
@@ -78,6 +79,7 @@ export interface WebProbeSentinelService {
overview(): Record;
dashboardRuns(url: URL): Record;
runDetail(runId: string): Record;
+ errorDetail(errorId: string): Promise>;
findings(url: URL): Record;
runViews(runId: string, view: string | null, url: URL): Record;
maintenance(): MaintenanceState;
@@ -207,6 +209,9 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
runDetail(runId: string) {
return dashboardRunDetail(config, db, runId);
},
+ errorDetail(errorId: string) {
+ return dashboardErrorDetail(config, db, errorId, { findingsForRow: (row, limit) => visibleFindingsForRun(config, db, row, limit, readAnalysisArtifactSummaryForRun(config, row, limit)), runSummary: (row) => dashboardRunSummary(config, db, row), traceability: (row) => runTraceability(config, row) });
+ },
findings(url: URL) {
return dashboardFindings(config, db, url);
},
@@ -321,6 +326,11 @@ async function sentinelFetch(service: WebProbeSentinelService, request: Request)
if (request.method === "GET" && pathname === "/api/overview") return jsonResponse(service.overview());
if (request.method === "GET" && pathname === "/api/runs") return jsonResponse(service.dashboardRuns(url));
if (request.method === "GET" && pathname === "/api/findings") return jsonResponse(service.findings(url));
+ const errorDetailMatch = /^\/api\/errors\/([^/]+)$/u.exec(pathname);
+ if (request.method === "GET" && errorDetailMatch !== null) {
+ const result = await service.errorDetail(decodeURIComponent(errorDetailMatch[1]));
+ return jsonResponse(result, result.ok === false ? 404 : 200);
+ }
const runViewsMatch = /^\/api\/runs\/([^/]+)\/views$/u.exec(pathname);
if (request.method === "GET" && runViewsMatch !== null) {
const view = url.searchParams.get("view");
@@ -941,7 +951,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
if (row === null) return { ok: false, error: "run-not-found", runId, valuesRedacted: true };
const stored = readMetadata(db, `run.report.${runId}`) ?? {};
const artifactSummary = readAnalysisArtifactSummaryForRun(config, row, dashboardMaxPageSize(config));
- const findings = visibleFindingsForRun(config, db, row, dashboardMaxPageSize(config), artifactSummary);
+ const findings = decorateRunFindingsWithErrorDetails(config, row, visibleFindingsForRun(config, db, row, dashboardMaxPageSize(config), artifactSummary));
const views = record(stored.views);
const reportJsonSha256 = stringOrNull(row.report_json_sha256) ?? stringOrNull(artifactSummary.reportJsonSha256);
return {
diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts
index a4d6b330..9d3ea37b 100644
--- a/scripts/src/hwlab-node/web-probe-observe.ts
+++ b/scripts/src/hwlab-node/web-probe-observe.ts
@@ -54,9 +54,10 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
&& sentinelActionRaw !== "validate"
&& sentinelActionRaw !== "maintenance"
&& sentinelActionRaw !== "dashboard"
+ && sentinelActionRaw !== "error"
&& sentinelActionRaw !== "report"
) {
- throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|report --node NODE --lane vNN [--dry-run|--confirm]");
+ throw new Error("web-probe sentinel usage: sentinel plan|status|image|control-plane|publish-current|validate|maintenance|dashboard|error|report --node NODE --lane vNN [--dry-run|--confirm]");
}
assertKnownOptions(args, new Set([
"--node",
@@ -67,6 +68,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
"--view",
"--run",
"--run-id",
+ "--error-id",
"--trace-id",
"--sample-seq",
"--sentinel",
@@ -156,6 +158,12 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
fullPage: args.includes("--full-page") && !args.includes("--no-full-page"),
raw: args.includes("--raw"),
};
+ } else if (sentinelActionRaw === "error") {
+ const errorAction = args[1];
+ if (errorAction !== "get") throw new Error("web-probe sentinel error usage: error get --node NODE --lane vNN --sentinel ID --error-id wbe_...");
+ const errorId = requiredOption(args, "--error-id");
+ if (!/^wbe_[a-f0-9]{20}$/u.test(errorId)) throw new Error(`web-probe sentinel error get --error-id must look like wbe_<20 hex>, got ${errorId}`);
+ sentinel = { kind: "error", action: "get", node, lane, sentinelId, errorId, raw: args.includes("--raw"), full: args.includes("--full"), timeoutSeconds };
} else {
const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary");
const latest = args.includes("--latest");
diff --git a/scripts/verify-web-probe-sentinel-monitor-web.ts b/scripts/verify-web-probe-sentinel-monitor-web.ts
index aef86a64..66db40ee 100644
--- a/scripts/verify-web-probe-sentinel-monitor-web.ts
+++ b/scripts/verify-web-probe-sentinel-monitor-web.ts
@@ -16,6 +16,13 @@ const checks: Array<{ readonly path: string; readonly contains: readonly string[
"data-memory-axis-y",
"readMonitorDeepLink",
"data-monitor-deep-link-focus",
+ "/api/errors/",
+ "data-error-id",
+ "data-check-deep-link",
+ "data-copy-detail-link",
+ "data-copy-error-id",
+ "data-otel-trace-summary",
+ "data-sentinel-evidence-summary",
"rootCause",
],
},